
/*
 * Copyright (C) 2008-2009 Archie L. Cobbs. All rights reserved.
 *
 * $Id: Database.java 377 2010-03-11 20:50:18Z archie.cobbs $
 */

package org.dellroad.sidekar;

import java.util.Collections;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

import org.dellroad.sidekar.datastore.Datastore;
import org.dellroad.sidekar.query.JoinType;
import org.dellroad.sidekar.query.QueryIterable;
import org.dellroad.sidekar.query.QuerySet;
import org.dellroad.sidekar.schema.SchemaMismatchException;
import org.dellroad.sidekar.schema.change.SchemaChange;
import org.dellroad.sidekar.schema.model.SchemaModel;
import org.dellroad.sidekar.util.CloseableNotes;
import org.dellroad.sidekar.util.SidekarException;

/**
 * A Sidekar database.
 *
 * <p>
 * A Sidekar database is a persistent store containing instances of one or more {@link Entity}s. An entity is just
 * a data structure, and can be thought of as a Java class with only fields, i.e., without any methods. Like Java classes,
 * entities may extend other entities (inheriting all the parent's fields), and may be marked abstract and therefore
 * not instantiable.
 * </p>
 *
 * <p>
 * Entities are represented in the Java world by dynamically created Java classes. These classes extend the
 * corresponding model classes supplied by the user (see below), and in addition implement the {@link Item} inteface.
 * </p>
 *
 * <p>
 * To create a Sidekar database, construct an instance of this class providing your entity hierarchy (schema) information,
 * in the form of a {@link SchemaModel} object (generated from your model classes containing
 * {@link org.dellroad.sidekar.annotation Sidekar annotations} by an
 * {@link org.dellroad.sidekar.annotation.AnnotationReader AnnotationReader}),
 * and optionally providing any {@link DatabaseUpdate}s that may be needed to update a previously created database whose
 * contents and/or structure may need to be changed.
 * </p>
 *
 * <p>
 * Once opened, operations on a {@link Database} that may create iterators may be performed within a call to
 * {@link CloseableNotes#performAction}. This ensures that any iterators
 * are closed. To close (and free) cursors more quickly,
 * use nested invocations of {@link CloseableNotes#performAction} as desired. Often these methods can be set up
 * to execute automatically, e.g., using {@link org.dellroad.sidekar.spring.SidekarFilter} or
 * {@link org.dellroad.sidekar.spring.SidekarHandlerInterceptor}.
 * </p>
 *
 * <p>
 * To create new entity instances, use {@link Entity#create Entity.create()}. To remove an instances from the database, use
 * {@link Item#sidekarDetach}.
 * </p>
 *
 * <p>
 * Invoke {@link #close} to close the database when finished. However, there is nothing wrong with leaving the database
 * open for a long period.
 * </p>
 *
 * <p>
 * Instances support {@linkplain org.dellroad.sidekar.annotation.GarbageCollector garbage collection} using a mark/sweep algorithm.
 * The algorithm optimizes the work to be performed based on static analysis of the database schema, only generating roots and
 * following references that can lead to garbage candidates. Depending on the schema and the entities to be collected, this can
 * greatly reduce collection times.
 * </p>
 */
public class Database {

    private final Datastore datastore;

    private Opening opening;
    private SchemaModel schemaModel;
    private Schema schema;                                // non-null if and only if open

// Constructors

    /**
     * Convenience constructor to use when no schema information is known ahead of time.
     * The schema can be obtained from this instance after it has been opened.
     * All entity and field access will have to be done via Sidekar reflection.
     *
     * <p>
     * Equivalent to:
     *  <blockquote>
     *  {@code Database(datstore, null)}
     *  </blockquote>
     *
     * @param datastore Underlying data storage
     * @throws IllegalArgumentException if name is empty or contains periods, slashes, or dollar signs
     */
    public Database(Datastore datastore) {
        this(datastore, null);
    }

    /**
     * Primary constructor.
     *
     * @param datastore Underlying data storage
     * @param schemaModel the expected schema; a value of {@code null} means the schema is unknown
     * @throws IllegalArgumentException if {@code schemaModel} is invalid
     */
    public Database(Datastore datastore, SchemaModel schemaModel) {
        if (datastore == null)
            throw new IllegalArgumentException("null datastore");
        if (schemaModel != null)
            schemaModel.validate();
        this.datastore = datastore;
        this.schemaModel = schemaModel;
    }

// Public methods

    /**
     * Open this database.
     *
     * <p>
     * The database will be created if it doesn't already exist.
     *
     * @throws IllegalArgumentException if configured schema is invalid
     * @throws SchemaMismatchException if the schema detected by the datastore does not match
     *  a non-null {@link SchemaModel} provided to the constructor
     * @throws IllegalStateException if this database is already open
     */
    public synchronized void open() {

        // Check state
        if (this.opening != null)
            throw new IllegalStateException("already open");

        // Prepare for unwinding
        boolean datastoreOpen = false;
        boolean schemaUpdated = false;
        SchemaModel originalSchemaModel = this.schemaModel;
        try {

            // Open datastore
            this.datastore.datastoreOpen();
            datastoreOpen = true;

            // Retrieve and verify datastore schema
            SchemaModel datastoreSchemaModel = this.datastore.schemaRetrieve();
            if (this.schemaModel != null)
                this.schemaModel.checkSameSchema(datastoreSchemaModel);
            else
                this.schemaModel = datastoreSchemaModel;
            schemaUpdated = true;

            // Build runtime schema
            this.schema = new Schema(this, this.schemaModel);

            // Done
            this.opening = new Opening();
        } finally {
            if (this.opening == null) {
                this.schema = null;
                if (schemaUpdated)
                    this.schemaModel = originalSchemaModel;
                if (datastoreOpen)
                    this.datastore.datastoreClose();
            }
        }
    }

    /**
     * Close this database.
     *
     * @throws IllegalStateException if this database is not open
     */
    public synchronized void close() {

        // Check state
        if (this.opening == null)
            throw new IllegalStateException("not open");

        // Shut down datastore
        this.datastore.datastoreClose();

        // Forget runtime schema
        this.schema = null;

        // Done
        this.opening = null;
    }

    /**
     * Get the {@link SchemaModel} describing this database's schema.
     *
     * <p>
     * If this instance was created with a {@code null} {@link SchemaModel}, then this will return the schema
     * that was read from the datastore once {@link #open} has been invoked.
     * </p>
     *
     * @throws IllegalStateException if this database is not currently open
     */
    public synchronized SchemaModel getSchemaModel() {
        if (this.opening == null)
            throw new IllegalStateException("database is not open");
        return this.schemaModel;
    }

    /**
     * Get the {@link Datastore} underlying this instance.
     */
    public synchronized Datastore getDatastore() {
        return this.datastore;
    }

    /**
     * Get the runtime schema associated with this instance.
     *
     * @return runtime schema if this instance is open, {@code null} otherwise
     */
    public synchronized Schema getSchema() {
        return this.schema;
    }

    /**
     * Get the {@link Entity} represented by the given Java model class.
     *
     * <p>
     * This is a convenience method, equivalent to:
     * <blockquote><pre>
     *  getSchema().getEntity(modelClass)
     * </pre></blockquote>
     * </p>
     *
     * @param modelClass user database model class
     * @throws IllegalArgumentException if the model class is not associated with any entity in this database
     * @throws IllegalStateException if this {@link Database} is not currently open
     */
    public final synchronized <T> Entity<T> getEntity(Class<T> modelClass) {
        if (this.opening == null)
            throw new IllegalStateException("database is not open");
        return this.schema.getEntity(modelClass);
    }

    /**
     * Get the {@link Entity} with the given name.
     *
     * <p>
     * This is a convenience method, equivalent to:
     * <blockquote><pre>
     *  getSchema().getEntity(name)
     * </pre></blockquote>
     * </p>
     *
     * @param name the name of the desired entity
     * @throws IllegalArgumentException if the named entity does not exist
     * @throws IllegalStateException if this {@link Database} is not currently open
     */
    public final synchronized Entity<?> getEntity(String name) {
        if (this.opening == null)
            throw new IllegalStateException("database is not open");
        return this.schema.getEntity(name);
    }

    /**
     * Get all entities in this database.
     *
     * <p>
     * This is a convenience method, roughly equivalent to:
     * <blockquote><pre>
     *  getSchema().getEntityMap().values()
     * </pre></blockquote>
     * </p>
     *
     * @return unmodifiable set of entities
     * @throws IllegalStateException if this {@link Database} is not currently open
     */
    public final synchronized Set<Entity<?>> getEntities() {
        if (this.opening == null)
            throw new IllegalStateException("database is not open");
        return Collections.unmodifiableSet(new HashSet<Entity<?>>(this.schema.getEntityMap().values()));
    }

    /**
     * Get all entities in this database, indexed by name.
     *
     * <p>
     * This is a convenience method, equivalent to:
     * <blockquote><pre>
     *  getSchema().getEntityMap()
     * </pre></blockquote>
     * </p>
     *
     * @return unmodifiable mapping from entity name to {@link Entity}
     * @throws IllegalStateException if this {@link Database} is not currently open
     */
    public final synchronized Map<String, Entity<?>> getEntityMap() {
        if (this.opening == null)
            throw new IllegalStateException("database is not open");
        return this.schema.getEntityMap();
    }

    /**
     * Get the singleton of the given type.
     *
     * <p>
     * This is a convenience method, equivalent to:
     * <blockquote><pre>
     *  getEntity(modelClass).getSingleton()
     * </pre></blockquote>
     * </p>
     *
     * @param modelClass user database model class
     * @throws IllegalArgumentException if the model class is not associated with any entity in this database
     * @throws IllegalStateException if this {@link Database} is not currently open
     */
    public final <T> T getSingleton(Class<T> modelClass) {
        return this.getEntity(modelClass).getSingleton();
    }

    /**
     * Create a new attached instance of a model class.
     *
     * <p>
     * The item's fields will have their default values.
     *
     * <p>
     * The item will remain in the database until:
     * <ul>
     *  <li>No other items reference the item; and</li>
     *  <li>{@link Item#sidekarDetach()} is invoked to explicitly detach the item</li>
     * </ul>
     *
     * <p>
     * This method just delegates to {@link Entity#create Entity.create()} using the {@link Entity}
     * correspoding to the given user model class.
     *
     * @param modelClass user database model class
     * @throws IllegalStateException if this database is not currently open
     * @throws IllegalArgumentException if the model class is not associated with any entity in this database
     * @throws IllegalArgumentException if the corresponding {@link Entity} is not instantiable
     * @see Entity#create
     */
    public final <T> T create(Class<T> modelClass) {
        return this.getEntity(modelClass).create();
    }

    /**
     * Join two query sets.
     *
     * <p>
     * In order to join two queries, the queries must be <i>join compatible</i>. The exact definition of
     * join compatible is determined by the underlying {@link Datastore}. For example, some {@link Datastore}s
     * may required that the two sets being joined iterate their elements with the same ordering.
     * </p>
     *
     * @param left left-side query
     * @param right right-side query
     * @param joinType type of join
     * @param <R> the type of element in the right query
     * @param <L> the type of element in the left query
     * @param <E> the type of element in the joined query
     * @throws IllegalArgumentException if {@code right} or {@code left} is null
     * @throws IllegalArgumentException if {@code joinType} is null
     * @throws IllegalArgumentException if {@code right} and {@code right} are not join-compatible
     * @throws IllegalStateException if this instance is not in the running state
     * @throws SidekarException if an error occurs
     */
    public <E, R extends E, L extends E> QueryIterable<E> join(QuerySet<L> left, QuerySet<R> right, JoinType joinType) {
        return this.getDatastore().join(left, right, joinType);
    }

    /**
     * Convenience method for inner joins. Equivalent to:
     *  <blockquote>
     *  <code>{@link #join(QueryIterable, QueryIterable, JoinType) join(left, right, JoinType.INNER)}</code>
     *  </blockquote>
     * @throws SidekarException if an error occurs
     */
    public <E, R extends E, L extends E> QueryIterable<E> join(QuerySet<L> left, QuerySet<R> right) {
        return this.join(left, right, JoinType.INNER);
    }

    /**
     * Determine if this instance is currently open.
     */
    public synchronized boolean isOpen() {
        return this.opening != null;
    }

// Package and private methods

    /**
     * Apply a schema change to the datastore.
     */
    synchronized void schemaChange(SchemaChange schemaChange) {
        this.datastore.schemaChange(schemaChange);
    }

    /**
     * Get validation token.
     */
    Opening getOpening() {
        if (this.opening == null)
            throw new RuntimeException("unexpected state - database is not open");
        return this.opening;
    }

    // Used to ensure related objects (Schema, Entity, etc.) are only used while this instance is open
    class Opening {

        /**
         * Validate this instance.
         */
        public void validate() {
            if (Database.this.opening != this)
                throw new IllegalStateException("the associated Database is no longer open");
        }
    }
}

